package com.googlecode.horncomb.web.spring.tags;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Collection;
import java.util.Map;

import javax.servlet.jsp.JspException;

import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.servlet.support.BindStatus;
import org.springframework.web.servlet.tags.form.AbstractHtmlInputElementTag;
import org.springframework.web.servlet.tags.form.OptionTag;
import org.springframework.web.servlet.tags.form.TagWriter;

import com.googlecode.horncomb.util.lang.BeanUtils;
import com.googlecode.horncomb.web.util.TagUtils;

public class SelectTag extends AbstractHtmlInputElementTag {
	private static final long serialVersionUID = 5655093139061237076L;

	private String firstEmpty = "true";

	/**
	 * The {@link javax.servlet.jsp.PageContext} attribute under which the bound
	 * value is exposed to inner {@link OptionTag OptionTags}.
	 */
	public static final String LIST_VALUE_PAGE_ATTRIBUTE = "org.springframework.web.servlet.tags.form.SelectTag.listValue";

	/**
	 * Marker object for items that have been specified but resolve to null.
	 * Allows to differentiate between 'set but null' and 'not set at all'.
	 */
	private static final Object EMPTY = new Object();

	/**
	 * The {@link Collection}, {@link Map} or array of objects used to generate
	 * the inner '<code>option</code>' tags.
	 */
	private Object items;

	/**
	 * The name of the property mapped to the '<code>value</code>' attribute of
	 * the '<code>option</code>' tag.
	 */
	private String itemValue;

	/**
	 * The name of the property mapped to the inner text of the '
	 * <code>option</code>' tag.
	 */
	private String itemLabel;

	/**
	 * The value of the HTML '<code>size</code>' attribute rendered on the final
	 * '<code>select</code>' element.
	 */
	private String size;

	/**
	 * Indicates whether or not the '<code>select</code>' tag allows
	 * multiple-selections.
	 */
	private Object multiple = Boolean.FALSE;

	/**
	 * The {@link TagWriter} instance that the output is being written.
	 * <p>
	 * Only used in conjunction with nested {@link OptionTag OptionTags}.
	 */
	private TagWriter tagWriter;

	/**
	 * Set the {@link Collection}, {@link Map} or array of objects used to
	 * generate the inner '<code>option</code>' tags.
	 * <p>
	 * Required when wishing to render '<code>option</code>' tags from an array,
	 * {@link Collection} or {@link Map}.
	 * <p>
	 * Typically a runtime expression.
	 * @param items
	 *            the items that comprise the options of this selection
	 */
	public void setItems(Object items) {
		this.items = (items != null ? items : EMPTY);
	}

	/**
	 * Get the value of the '<code>items</code>' attribute.
	 * <p>
	 * May be a runtime expression.
	 */
	protected Object getItems() {
		return this.items;
	}

	/**
	 * Set the name of the property mapped to the '<code>value</code>' attribute
	 * of the '<code>option</code>' tag.
	 * <p>
	 * Required when wishing to render '<code>option</code>' tags from an array
	 * or {@link Collection}.
	 * <p>
	 * May be a runtime expression.
	 */
	public void setItemValue(String itemValue) {
		this.itemValue = itemValue;
	}

	/**
	 * Get the value of the '<code>itemValue</code>' attribute.
	 * <p>
	 * May be a runtime expression.
	 */
	protected String getItemValue() {
		return this.itemValue;
	}

	/**
	 * Set the name of the property mapped to the label (inner text) of the '
	 * <code>option</code>' tag.
	 * <p>
	 * May be a runtime expression.
	 */
	public void setItemLabel(String itemLabel) {
		this.itemLabel = itemLabel;
	}

	/**
	 * Get the value of the '<code>itemLabel</code>' attribute.
	 * <p>
	 * May be a runtime expression.
	 */
	protected String getItemLabel() {
		return this.itemLabel;
	}

	/**
	 * Set the value of the HTML '<code>size</code>' attribute rendered on the
	 * final '<code>select</code>' element.
	 * <p>
	 * May be a runtime expression.
	 * @param size
	 *            the desired value of the '<code>size</code>' attribute
	 */
	public void setSize(String size) {
		this.size = size;
	}

	/**
	 * Get the value of the '<code>size</code>' attribute.
	 * <p>
	 * May be a runtime expression.
	 */
	protected String getSize() {
		return this.size;
	}

	/**
	 * Set the value of the HTML '<code>multiple</code>' attribute rendered on
	 * the final '<code>select</code>' element.
	 * <p>
	 * May be a runtime expression.
	 */
	public void setMultiple(Object multiple) {
		this.multiple = multiple;
	}

	/**
	 * Get the value of the HTML '<code>multiple</code>' attribute rendered on
	 * the final '<code>select</code>' element.
	 * <p>
	 * May be a runtime expression.
	 */
	protected Object getMultiple() {
		return this.multiple;
	}

	/**
	 * Renders the HTML '<code>select</code>' tag to the supplied
	 * {@link TagWriter}.
	 * <p>
	 * Renders nested '<code>option</code>' tags if the {@link #setItems items}
	 * property is set, otherwise exposes the bound value for the nested
	 * {@link OptionTag OptionTags}.
	 */
	protected int writeTagContent(TagWriter tagWriter) throws JspException {
		tagWriter.startTag("select");
		writeDefaultAttributes(tagWriter);
		if (isMultiple()) {
			tagWriter.writeAttribute("multiple", "multiple");
		}
		tagWriter.writeOptionalAttributeValue("size",
				getDisplayString(evaluate("size", getSize())));

		Object items = getItems();
		if (items != null) {
			// Items specified, but might still be empty...
			if (items != EMPTY) {
				Object itemsObject = (items instanceof String ? evaluate(
						"items", (String) items) : items);
				if (itemsObject != null) {
					String valueProperty = (getItemValue() != null ? ObjectUtils
							.getDisplayString(evaluate("itemValue",
									getItemValue()))
							: null);
					String labelProperty = (getItemLabel() != null ? ObjectUtils
							.getDisplayString(evaluate("itemLabel",
									getItemLabel()))
							: null);
					writeFirst(tagWriter);
					writeOptions(tagWriter, itemsObject, valueProperty,
							labelProperty);
					// optionWriter.writeOptions(tagWriter);
				}
			}
			tagWriter.endTag(true);
			writeHiddenTagIfNecessary(tagWriter);
			return EVAL_PAGE;
		} else {
			// Using nested <form:option/> tags, so just expose the value in the
			// PageContext...
			tagWriter.forceBlock();
			writeFirst(tagWriter);
			this.tagWriter = tagWriter;
			this.pageContext.setAttribute(LIST_VALUE_PAGE_ATTRIBUTE,
					getBindStatus());
			return EVAL_BODY_INCLUDE;
		}
	}

	private void writeFirst(TagWriter tagWriter) throws JspException {
		if (this.getFirstEmpty()) {
			tagWriter.forceBlock();
			tagWriter.startTag("option");
			tagWriter.writeAttribute("value", "");
			tagWriter.forceBlock();
			tagWriter.appendValue("");
			tagWriter.endTag();
		}
	}

	private void writeOptions(TagWriter tagWriter, Object itemsObject,
			String valueProperty, String labelProperty) throws JspException {
		try {
			Class<?> clz = ClassUtils
					.forName("org.springframework.web.servlet.tags.form.OptionWriter");
			Constructor<?> cs = clz.getDeclaredConstructor(new Class[] {
					Object.class, BindStatus.class, String.class, String.class,
					boolean.class });
			if (!cs.isAccessible() || cs.getModifiers() != Modifier.PUBLIC) {
				cs.setAccessible(true);
			}
			Object optionWriter = BeanUtils.instantiateClass(cs, new Object[] {
					itemsObject, getBindStatus(), valueProperty, labelProperty,
					isHtmlEscape() });
			Method m = clz.getDeclaredMethod("writeOptions",
					new Class[] { TagWriter.class });
			if (clz.getModifiers() != Modifier.PUBLIC
					|| m.getModifiers() != Modifier.PUBLIC) {
				m.setAccessible(true);
			}
			m.invoke(optionWriter, new Object[] { tagWriter });
		} catch (Exception ex) {
			throw new JspException(ex);
		}
	}

	/**
	 * If using a multi-select, a hidden element is needed to make sure all
	 * items are correctly unselected on the server-side in response to a
	 * <code>null</code> post.
	 */
	private void writeHiddenTagIfNecessary(TagWriter tagWriter)
			throws JspException {
		if (isMultiple()) {
			tagWriter.startTag("input");
			tagWriter.writeAttribute("type", "hidden");
			tagWriter.writeAttribute("name",
					WebDataBinder.DEFAULT_FIELD_MARKER_PREFIX + getName());
			tagWriter.writeAttribute("value", "1");
			tagWriter.endTag();
		}
	}

	private boolean isMultiple() throws JspException {
		Object multiple = getMultiple();
		if (Boolean.TRUE.equals(multiple) || "true".equals(multiple)
				|| "multiple".equals(multiple)) {
			return true;
		} else if (this.multiple instanceof String) {
			Object evaluatedValue = evaluate("multiple", multiple);
			return Boolean.TRUE.equals(evaluatedValue);
		}
		return forceMultiple();
	}

	/**
	 * Returns '<code>true</code>' if the bound value requires the resultant '
	 * <code>select</code>' tag to be multi-select.
	 */
	@SuppressWarnings("unchecked")
	private boolean forceMultiple() throws JspException {
		BindStatus bindStatus = getBindStatus();
		Class valueType = bindStatus.getValueType();
		if (valueType != null && typeRequiresMultiple(valueType)) {
			return true;
		} else if (bindStatus.getEditor() != null) {
			Object editorValue = bindStatus.getEditor().getValue();
			if (editorValue != null
					&& typeRequiresMultiple(editorValue.getClass())) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Returns '<code>true</code>' for arrays, {@link Collection Collections}
	 * and {@link Map Maps}.
	 */
	@SuppressWarnings("unchecked")
	private static boolean typeRequiresMultiple(Class type) {
		return (type.isArray() || Collection.class.isAssignableFrom(type) || Map.class
				.isAssignableFrom(type));
	}

	/**
	 * Closes any block tag that might have been opened when using nested
	 * {@link OptionTag options}.
	 */
	public int doEndTag() throws JspException {
		if (this.tagWriter != null) {
			this.tagWriter.endTag();
			writeHiddenTagIfNecessary(tagWriter);
		}
		return EVAL_PAGE;
	}

	/**
	 * Clears the {@link TagWriter} that might have been left over when using
	 * nested {@link OptionTag options}.
	 */
	public void doFinally() {
		super.doFinally();
		this.tagWriter = null;
		this.pageContext.removeAttribute(LIST_VALUE_PAGE_ATTRIBUTE);
	}

	/**
	 * @return the emptyFirst
	 * @throws JspException
	 */
	public boolean getFirstEmpty() throws JspException {
		return TagUtils.getBooleanValue("emptyFirst", this.firstEmpty,
				pageContext);
	}

	/**
	 * @param emptyFirst
	 *            the emptyFirst to set
	 */
	public void setFirstEmpty(String emptyFirst) {
		this.firstEmpty = emptyFirst;
	}
}
